실전속의 디자인 패턴
디자인 패턴은 발명되는 것이 아니라 발견되는 것이다. 코드에 반복적으로 같은 내용이 출현할 때 비로소 일반적이고 추상화된 클래스, 객체 또는 컴포넌트의 패턴이 발견되는 것이다.
구조(structural) 패턴
구조 패턴은 인터페이스를 복잡하게 하지 않으면서도 기능을 확장하여 더 강력한 인터페이스 또는 객체를 만들어야 하는 상황에서 유용하다.
어댑터 패턴
래퍼(Warpper)라고도 불린다. 호환되지 않는 두 개 이상의 객체에 대한 인터페이스를 동시에 사용할 수 있게 한다. 객체를 직접 사용하는 대신에 해당 객체를 수용할 수 있는 새로운 인터페이스를 개발할 수 있다.
- 기존 클래스를 상속받는 새로운 클래스를 만드는 방법
from _adapter_base import UsernameLookup # 외부의 코드 임포트 class UserSoirce(UsernameLookup): # 상속을 통해 새로운 클래스 생성 def fetch(self, user_id, username): # 해당 메서드 실행 user_namespace = self._adapt_arguments(user_id, username) return self.search(user_namespace) # search 라는 외부 의존성 객체 존재 @staticmethod def _adapt_arguments(user_id, username): return f"{user_id}:{username}"
이 방법은 사용 가능한 방법이지만, 권장하지 않는다.
- 상속을 하면 결합도가 높아지며, 유연성은 떨어진다.
- 외부 라이브러리를 정확히 이해하지 못했다면 사용하지 않는 것이 낫다.
- 상속은 is-a 관계에 한정해 적용하는 것이 올바르다.
- 라이브러리 내에 얼마나 많은 메서드가 있는지 알 수 없다.
from _adapter_base import UsernameLookup class UserSource: def __init__(self): self.username_lookup = UsernameLookup() def fetch(self, user_id, username): user_namespace = self._adapt_arguments(user_id, username) return self.username_lookup.search(user_namespace) @staticmethod def _adapt_arguments(user_id, username): return f"{user_id}:{username}"
UserSource
는 UsernameLookup
를 직접 상속받지 않았다. UserSource
는 UsernameLookup
의 내부 구현에 의존하기보다 필요한 메서드(search()
)에 의존한다. 상속에 비해서 결합도는 낮추고, 유연성은 높였다.여러 메서드에서 이와 같은 작업을 해야 한다면,
__getattr__
사용UserSource
클래스는UsernameLookup
의 프록시 또는 래퍼 역할을 하게 된다.UserSource
클래스에서UsernameLookup
클래스의 어떤 메서드에도 직접적으로 접근하지 않는다.- 모든 메서드 호출을
UsernameLookup
인스턴스로 자동으로 전달할 수 있다. - 너무 일반적이라 위험하고, 예상치 못한 부작용이 있을 수 있다.
- 데코레이터 사용
- 함수나 메서드를 감싸는 래퍼(wrapper) 함수 역할
- 기존 코드를 변경하지 않고, 객체에 추가 기능을 동적으로 추가할 수 있게 하는 디자인 패턴
__getattr__
과 비교해서 오류를 추적하고 디버깅하기가 더 쉽다- 특정 함수나 메서드에 대한 변경을 격리하여, 전체 클래스에 미치는 영향을 최소화한다.
__getattr__
을 사용할 때보다 예상치 못한 부작용의 리스크를 줄일 수 있다.
컴포지트(composite)
객체들을 트리 구조로 구성하여 개별 객체와 복합 객체를 동일하게 취급할 수 있도록 한다. 목적은 클라이언트가 단일 객체(Leaf)와 객체의 집합(Composite)을 구분하지 않고 동일한 방식으로 다룰 수 있도록 하는 데 있다. 이를 통해 코드의 유연성과 재사용성이 증가한다.
class Product: def __init__(self, name: str, price: float) -> None: self._name = name self._price = price @property def price(self) -> float: return self._price class ProductBundle: def __init__( self, name: str, perc_discount: float, *products: Iterable[Union[Product, "ProductBundle"]] ) -> None: self._name = name, self._perc_discount = perc_discount, self._products = products @property def price(self) -> float: total = sum(p.price for p in self._products) return total * (1 - self._perc_discount)
# 개별 상품 생성 product1 = Product("Laptop", 1000) product2 = Product("Phone", 500) # 상품 묶음 생성 bundle1 = ProductBundle("Electronics Pack", 0.1, product1, product2) # 또 다른 상품과 상품 묶음 생성 product3 = Product("Tablet", 300) bundle2 = ProductBundle("Super Pack", 0.15, bundle1, product3)
데코레이터
파이썬의 데코레이터와 디자인 패턴에서의 데코레이터는 비슷하지만 다른 개념이다. 데코레이터 패턴은 객체에 새로운 책임(기능)을 동적으로 첨가할 수 있도록 한다. 상속을 사용하는 것보다 더 유연한 대안을 제공한다. 객체의 기능을 수정하거나 확장해야 할 때 유용하며, 다중 상속의 복잡성을 피하면서도 코드의 재사용성을 증가시킵니다.
디자인 패턴의 데코레이터는 다음과 같은 구성 요소로 이루어져 있다.
- Component(구성요소): 원래 객체의 인터페이스를 정의합니다.
- ConcreteComponent(구체적 구성요소): Component를 구현하는 클래스로, 추가될 새로운 기능이 없는 기본 객체이다.
- Decorator(데코레이터): Component와 동일한 인터페이스를 가지며, 하나 이상의 Component(ConcreteComponent 또는 다른 Decorator)를 포함한다.
- ConcreteDecorator(구체적 데코레이터): Decorator를 구현하는 클래스로, Component에 추가 기능을 더한다.
class DictQuery: # Component, ConcreteComponent def __init__(self, **kwargs): self._raw_query = kwargs def render(self) -> None: # 기본 인터페이스를 정의 return self._raw_query
class QueryEnhancer: # Decorator def __init__(self, query: DictQuery): self.decorated = query def render(self): return self.decorated.render() class RemoveEmpty(QueryEnhancer): # ConcreteDecorator def render(self): original = super().render() return {k: v for k, v in original.items() if v} class CaseInsensitive(QueryEnhancer): # ConcreteDecorator def render(self): original = super().render() return {k: v.lower() for k, v in original.items()}
>>> original = DictQuery(key="value", empty="", none=None, upper="UPPERCASE", title="Title") >>> new_query = CaseInsensitive(RemoveEmpty(original)) >>> original.render() {"key": "value", "empty": "", "none": None, "upper": "UPPERCASE", "title": "Title"} >>> new_queryset.render() {"key": "value", "upper": "uppercase", "title": "title"}
파사드(Facade)
건물의 가장 중요한 면을 가리키는 단어로 보통은 ‘전면’을 말한다. 소프트웨어 공학에서는 복잡한 시스템을 가려주는 단일 통합 창구 역할을 하는 객체를 말한다. 허브 또는 단일 참조점 역할을 한다.
객체에 대한 모든 연결을 만드는 대신 파사드 역할을 하는 중간 객체를 만드는 것이다. 파사드 패턴을 사용하면 객체의 결합력을 낮춰주는 확실한 장점과 인터페이스 개수를 줄이고, 보다 나은 캡슐화를 지원할 수 있다.
API를 설계할 때, 파사드 패턴을 이용할 수 있다. 파사드 패턴을 활용해 단일 인터페이스를 제공하고 ‘단일 진리점’ 혹은 ‘진입점’ 역할을 하여 사용자가 노출된 기능을 쉽게 사용할 수 있다. 기능만 노출하고 나머지 모든 것은 인터페이스 뒤로 숨긴다.
파이썬 디렉토리의 패키지를 빌드할 때는
__init__.py
파일을 둔다. 이것이 루트로서 파사드 역할을 한다.
init 파일의 API가 유지되는 한 클라이언트에 영향을 주지 않게 된다. 이러한 것들이 유지보수가 가능한 소프트웨어를 만들기 위해서 지켜야하는 가장 중요한 원칙이다.
class Engine: def start(self): print("Engine starts!") class AirConditioner: def turn_on(self): print("AirConditioner turns on!") class Radio: def play(self): print("Radio plays!") # 파사드를 적용한 클래스 class CarFacade: def __init__(self): self.engine = Engine() self.air_conditioner = AirConditioner() self.radio = Radio() def start_car(self): self.engine.start() self.air_conditioner.turn_on() self.radio.play() # 클라이언트 코드 car = CarFacade() car.start_car()
행동(behavioral) 패턴
객체가 어떻게 협력해야 하는지, 어떻게 통신해야 하는지, 런타임 중에 인터페이스는 어떤 형태여야 하는지에 대한 문제를 해결하는 것을 목표로 한다.
이러한 문제는 정적으로는 상속을 통해, 동적으로는 컴포지션을 통해 해결될 수 있다. 결국엔 중복을 피하거나 행동을 캡슐화하는 추상화를 통해 모델 간의 결합력을 낮춤으로써 훨씬 좋은 코드를 만들게 된다는 점을 알 수 있다.
책임 연쇄 패턴
import re from typing import Optional, Pattern class Event: pattern: Optional[Pattern[str]] = None def __init__(self, next_event=None): self.successor = next_event def process(self, logline:str): if self.can_process(logline): return self._process(logline) if self.successor is not None: return self.successor.process(logline) def _process(self, logline:str) -> dict: parsed_data = self._parsed_data(logline) return { "type": self.__class__.__name__, "id": parsed_data["id"], "value": parsed_data["value"], } @classmethod def can_process(cls, logline: str) -> bool: return ( cls.pattern is not None and cls.pattern.match(logline) is not None ) @classmethod def _parse_data(cls, logline: str) -> dict: if not cls.pattern: return {} if (parsed := cls.pattern.match(logline)) is not None: return parsed.groupdict() return {} class LoginEvent(Event): pattern = re.compile(r"(?P<id>\d+):\s+login\s+(>P<value>\S+)" class LogoutEvent(Event): pattern = re.compile(r"(?P<id>\d+):\s+logout\s+(>P<value>\S+)"
이전 장의 이벤트 시스템은 개방/패쇄 원칙을 따르고,
__subclasses__()
매직 메서드를 구현해 적절한 이벤트를 찾고 책임을 묻는 형태로 구현했었다. 이 원칙을 따르면 추가적인 이점이 있다. successor
(후계자)의 개념이 추가됐다. 현재의 이벤트 객체가 로그 라인을 처리할 수 없을 경우에 대비해 준비해 놓은 다음 이벤트 객체이다. 직접 처리가 가능한 경우 결과를 반환하고, 불가능하면 후계자에게 전달하고 이 과정을 반복한다.>>> chain = LogoutEvent(LoginEvent()) >>> chain.process("567: login User") {"type": "LoginEvent", "id": "567", "value": "User"}
class SessionEvent(Event): pattern = re.compile(r"(?P<id>\d+):\s+logout\s+(>P<value>\S+)"
chain = SessionEvent(LoginEvent(LogoutEvent))
__subclasses__()
매직 메서드를 구현해 이벤트 목록을 구현했었는데, 물론 직접 이벤트 리스트를 만들어서 처리하면 우선 순위를 조정할 수 있다. chain
을 활용한 책임 연쇄 패턴을 활용하면, 더 직관적으로 연결하고 조정할 수 있다. 또한 이전 장의 이벤트 시스템 의 meets_conditions()
메서드에 의존하는 것보다 이렇게 객체에 패턴을 적용하는 것이 훨씬 유연한 방법이다.
댓글